C++17 新特性
C++17 是一个「中」规模版本(它本来应该是一个 major 版本)。虽然没有模块/协程级别的变革,但结构化绑定、if constexpr、CTAD、string_view/optional/variant 这一族词汇类型的实用性极高,日常代码风格从 C++17 开始明显变化。本文按语言与标准库两侧逐项展开,附用法、原理与陷阱。
编译器支持情况见 Compiler support for C++17。系列总览见 现代 C++ 新特性汇总。
语言特性
保证拷贝省略
纯右值(prvalue)初始化对象时强制省略拷贝/移动——不再依赖编译器优化,语言直接保证:
struct Immovable {
Immovable() = default;
Immovable(const Immovable&) = delete; // 不可拷贝
Immovable& operator=(const Immovable&) = delete;
};
Immovable make() { return Immovable{}; } // OK:无拷贝无移动
Immovable x = make(); // 依然 OK:直接在 x 的位置构造 原理:C++17 重新定义了 prvalue——它不再是「一个临时对象」,而是「构造某个对象的配方」,直到真正需要对象的位置(物化)才构造。因此 T a = T(T(T())); 一趟构造直达终点。返回不可移动类型从此合法,return 局部变量也少了一层移动开销担忧。
按值捕获 *this
lambda 捕获列表支持 [*this],把当前对象拷贝一份进闭包(P0018R3):
struct Sensor {
int reading{};
auto snapshot() const {
return [*this] { return reading; }; // 拷贝整个对象进闭包
}
// [this] 捕获的是指针:lambda 活得比对象久就悬垂
// [*this] 捕获副本:适合异步回调、跨线程任务
}; C++20 的相关变化是 [=] 隐式捕获 this 被弃用(应写 [=, this]),见 C++20 新特性。
超对齐类型的动态分配
new 会正确处理对齐要求超过默认值(通常 16 字节)的类型:
struct alignas(64) CacheLine { // 对齐到缓存行
char data[64];
};
auto* p = new CacheLine; // 自动调用 operator new(size, align_val_t{64})
delete p; // 匹配的对齐 delete C++17 之前,new 不感知 alignas,超对齐对象在堆上可能不对齐(SIMD、无锁结构的经典坑)。现在由语言保证。
严格指定运算顺序
若干二元运算的操作数求值顺序从「未指明」改为「有确定顺序」:
// C++17 规则:
// E1 = E2 先求右侧 E2
// E1[E2] 先求下标 E2
// E1 << E2 从左到右(<<、>>、&、|、^、逗号等)
std::map<int, int> m;
m[0] = m.size(); // 定义良好:先算 m.size()(此时是 0),再插入 m[0]
std::cout << f() << g(); // f() 一定先于 g() 执行
// 注意:函数实参之间的求值顺序仍然未指明 这消除了大量「未指定但经常碰巧正确」的隐患,不过实参顺序依旧不保证,旧习惯别全丢。
UTF-8 字符字面量
C++17 新增 u8 字符字面量 u8'a'(此前 u8"..." 字符串 C++11 已有):
char c = u8'a'; // UTF-8 编码的字符字面量
// u8"你好" // UTF-8 字符串字面量(C++11 起)
// C++20 起 u8 字面量的类型改为新的 char8_t,见 C++20 一篇 十六进制浮点字面量
0x 前缀 + p 指数的浮点写法,与硬件浮点格式(二进制)精确对应:
double x = 0x1.8p3; // 1.5 × 2^3 = 12.0
// 0x1.8:十六进制尾数(1 + 8/16 = 1.5)
// p3:2 的 3 次方(p = power of 2,指数是十进制) 用途:浮点常量的位级精确表示(DSP、常量表),十进制字面量是做不到的。
折叠表达式
变参模板的参数包不再需要递归展开,一行折叠:
template<typename... Args>
auto sum(Args... args) {
return (args + ... + 0); // 二元左折叠,空包安全返回 0
}
template<typename... Args>
void print_all(const Args&... args) {
((std::cout << args << ' '), ...); // 一元左折叠:依次执行每个表达式
} 四种形式:
| 写法 | 名称 | 结合方式 |
|---|---|---|
(pack op ...) | 一元右折叠 | E1 + (E2 + E3) |
(... op pack) | 一元左折叠 | (E1 + E2) + E3 |
(pack op ... op init) | 二元右折叠 | E1 + (E2 + init) |
(init op ... op pack) | 二元左折叠 | (init + E1) + E2 |
一元折叠空包是错误(逗号、&&、|| 除外),所以求和用二元形式给初值 0。
泛型值模板参数
非类型模板参数可以用 auto,让编译器推导其类型:
template<auto V> // V 的类型由实参推导
struct Constant {
static constexpr auto value = V;
};
Constant<42> a; // V 是 int
Constant<'x'> b; // V 是 char
Constant<sizeof(int)> c; // V 是 size_t 类型的值
// C++20 进一步允许任意字面类类型做模板参数 类模板参数的类型推导(CTAD)
类模板的模板参数可以从构造函数实参推导,不用手写模板参数列表:
std::vector v{1, 2, 3}; // vector<int>(以前必须 vector<int>)
std::pair p{1, 2.5}; // pair<int, double>
std::lock_guard lk(mtx); // lock_guard<std::mutex>
std::atomic n{0}; // atomic<int>
// 复杂推导靠"推导指引":
std::vector<int> src{1, 2, 3};
std::vector vec(src.begin(), src.end()); // 迭代器对 -> vector<int>,是指引的功劳 编译时 if(if constexpr)
if constexpr 在编译期剪掉不成立的分支,被剪分支甚至不要求能通过编译——替代大量 SFINAE:
template<typename T>
std::string stringify(const T& v) {
if constexpr (std::is_pointer_v<T>)
return stringify(*v); // T 是 int 等非指针类型时,这行根本不实例化
else if constexpr (std::is_same_v<T, bool>)
return v ? "true" : "false";
else
return std::to_string(v);
} 与普通 if 的本质区别:普通 if 两个分支都要编译(只是运行期跳过),if constexpr 的死分支在编译期就消失,所以才能写「对指针才合法」的代码。
带有初始值设定项的选择语句
if / switch 的条件位置可以带一条初始化语句,变量作用域限制在语句内:
// 以前 it 泄漏到外面作用域
if (auto it = m.find(key); it != m.end()) {
use(it->second);
} else {
it = m.emplace(key, 0).first; // else 分支也能用 it
}
// it 在这里已不存在,名字不污染外层
switch (auto s = status(); s) {
case Status::ok: handle_ok(); break;
case Status::retry: handle_retry(s); break;
default: break;
} constexpr 匿名函数
lambda 隐式就是 constexpr(只要满足要求),也可以显式标注:
constexpr auto sq = [](int x) { return x * x; }; // 可在编译期调用
static_assert(sq(8) == 64, "");
constexpr int table[]{sq(1), sq(2), sq(3)}; // 1, 4, 9 编译期生成 配合放松的 constexpr 规则,编译期查表代码可以直接用 lambda 写。
inline 变量
变量可以 inline,头文件里定义、跨翻译单元合并为一份实体:
// config.h
struct Config {
static inline int log_level = 3; // 以前必须在某个 .cpp 里再定义一次
};
inline constexpr double kGravity = 9.80665; // 头文件常量不再需要每个 cpp 一份 解决的问题:C++11 的 static 成员变量或全局变量放头文件会导致「每个包含它的 .cpp 各有一份副本 / 重复定义」,只能声明与定义分离。inline 变量(含 inline constexpr)是头文件库的最后一块拼图。
结构化绑定
把 tuple/pair/结构体/数组的成员一次性解包到具名变量:
std::map<std::string, int> ages{{"tom", 30}, {"jerry", 25}};
for (const auto& [name, age] : ages) // 遍历 map 的最自然写法
std::cout << name << ':' << age << '\n';
auto [ok, value] = try_parse("42"); // 多返回值
struct Point { int x; double y; };
auto [x, y] = Point{1, 2.5}; // 结构体成员
int arr[3]{1, 2, 3};
auto [a, b, c] = arr; // 原生数组 原理:编译器生成一个隐藏的引用/拷贝对象,再把各个名字绑定到它的成员上——是绑定不是新变量,所以不能嵌套、个数必须对得上。
新的标准属性
三个新的 [[...]] 属性,把「意图」告诉编译器以产生告警或优化:
[[nodiscard]] int compute(); // 调用方丢弃返回值 -> 告警(错误码防漏检)
void handle(int mode) {
switch (mode) {
case 1:
prepare();
[[fallthrough]]; // 显式贯穿到 case 2,压掉告警
case 2:
run();
break;
default:
break;
}
}
void debug_hook([[maybe_unused]] int verbose) { // 某些构建里没用到也不告警
// ...
} 用底层类型的值来初始化 enum 类型
底层类型固定的枚举,可以直接从该类型的值构造(用于组合标志位):
enum class Flags : std::uint32_t { A = 1, B = 2, BOTH = 3 };
Flags f{0}; // 合法:花括号从底层类型初始化
Flags g{Flags::B | Flags::A}; // 注意 | 不接受(未定义枚举值的运算),仍是老问题
auto raw = static_cast<std::uint32_t>(f); 一些小的扩展
namespace app::net::detail { // 嵌套命名空间定义(以前只能一层层写)
}
#if __has_include(<optional>) // 探测头文件是否存在
# include <optional>
#endif 标准库特性
文件系统
<filesystem> 提供跨平台的路径、目录、文件操作:
namespace fs = std::filesystem;
fs::path p = "data/logs/app.txt";
fs::create_directories(p.parent_path()); // 递归建目录
for (const auto& entry : fs::directory_iterator("data")) {
if (entry.is_regular_file())
std::cout << entry.path().filename() << ' '
<< fs::file_size(entry) << '\n';
}
auto copied = fs::copy_file("a.txt", "b.txt",
fs::copy_options::overwrite_existing); 关键点:fs::path 是可移植路径抽象(Windows 上 / 会规范化);遍历目录产生的异常用 std::filesystem::filesystem_error 捕获。
并行算法
STL 算法接受执行策略参数,一行切换多线程/向量化:
#include <execution>
std::sort(std::execution::par, v.begin(), v.end()); // 多线程排序
std::for_each(std::execution::par, v.begin(), v.end(),
[](double& x) { x = std::sqrt(x); });
std::count_if(std::execution::par_unseq, v.begin(), v.end(), // 还可向量化
[](int x) { return x % 2 == 0; });
// 三种策略:seq(串行,默认)/ par(多线程)/ par_unseq(多线程+向量化) 注意:并行下元素访问函数不能引入数据竞争、不能抛异常(抛出即 std::terminate),算法内部怎么分块由实现决定。
数学特殊函数
<cmath> 收录了一批科学计算特殊函数:beta、riemann_zeta、hermite、laguerre、assoc_legendre、椭圆积分等:
#include <cmath>
double b = std::beta(2.0, 3.0); // 贝塔函数
double z = std::riemann_zeta(2.0); // ζ(2) = π²/6
double h = std::hermite(3, 1.5); // 埃尔米特多项式 string_view
指向字符串的非拥有只读视图:一对(指针,长度),零拷贝传参的标准答案:
void log_msg(std::string_view sv); // 接受 string、字面量、子串,都不拷贝
std::string s = "hello world";
// 传统:s.substr(0, 5) 分配一个新 string
// 视图:
std::string_view whole = s;
std::string_view head = whole.substr(0, 5); // 只改指针和长度,零分配
log_msg(head);
// 两个坑:
// 1. 不保证 NUL 结尾,不能直接传给 C API
// 2. 引用的数据必须活得比视图久(返回局部 string 的 view 是悬垂) std::byte 类型
表示「裸字节」的独立类型(库特性,定义在 <cstddef>,P0298R3,常被误归入语言特性):只允许位运算、不允许算术,把「内存数据」和「数字」从类型上分开:
#include <cstddef>
std::byte b{0x0F};
b <<= 4; // 位运算:可用
b |= std::byte{0x01};
auto n = std::to_integer<int>(b); // 转数字必须显式
// b + 1; // 错误:没有算术运算
// 它本质是 enum class byte : unsigned char,比 unsigned char 多了类型安全 用途:内存缓冲、序列化代码里区分「字节」与「整数」,避免 char 的符号问题与意外的算术。
any
std::any 存任意类型的值(类型安全的 void*),类型集合开放时使用:
std::any a = 42;
a = std::string("hello"); // 随时换成别的类型
if (auto* ps = std::any_cast<std::string>(&a)) // 指针形式:类型不符得 nullptr
use(*ps);
try {
std::any_cast<int>(a); // 引用形式:类型不符抛 bad_any_cast
} catch (const std::bad_any_cast&) {} variant
std::variant<A, B, ...> 是类型安全的 union:同一时刻存候选类型之一,候选集合固定:
std::variant<int, std::string> v = "hello";
// 按类型访问
if (auto* ps = std::get_if<std::string>(&v))
use(*ps);
// 按当前内容分发(推荐)
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
std::visit(overloaded{
[](int i) { std::cout << "int " << i; },
[](const std::string& s) { std::cout << "str " << s; },
}, v); std::visit 对「当前是哪种类型」做编译期分发,overloaded 惯用法(配合 CTAD)让每个候选对应一个 lambda。
optional
std::optional<T> 表示「可能有值也可能没有」,替代魔法值和输出参数:
std::optional<int> parse_int(std::string_view sv) {
if (sv.empty()) return std::nullopt; // 显式的"没有"
return std::stoi(std::string(sv));
}
auto n = parse_int("42");
if (n) // 有值
use(*n);
int v = n.value_or(-1); // 没有就给默认 三个「词汇类型」按需求选:
invoke()
std::invoke 统一「一切可调用物」的调用语法(INVOKE 协议的落点):
struct Widget {
int twice() const { return x * 2; }
int x = 21;
};
Widget w;
std::invoke(&Widget::twice, w); // 调成员函数 -> 42
std::invoke(&Widget::x, w); // 访问成员 -> 21
std::invoke([](int x) { return x + 1; }, 1); // 普通可调用 -> 2 它是 std::function、bind、ranges 算法内部的基础设施;自己写泛型回调框架时也用它,成员函数指针就不用特判了。
to_chars() 和 from_chars()
<charconv> 的底层字符串/数值转换:无 locale、无分配、无异常,最快的转换接口:
char buf[32];
auto [ptr, ec] = std::to_chars(buf, buf + sizeof buf, 3.14159); // 写入,ec 表示成功与否
// buf 到 ptr 之间就是结果,没有 NUL 结尾
int value{};
auto [p2, ec2] = std::from_chars(buf, ptr, value); // 解析回去
// 与 stoi/sscanf 对比:不分配、不抛异常、不读 locale、不跳过前导空白
// 浮点也支持,格式控制有限(fixed/scientific/general) 多态分配器
<memory_resource>(pmr)把分配器变成容器类型的一部分,容器及其嵌套容器自动使用同一内存源:
#include <memory_resource>
std::byte stack_buf[1024];
std::pmr::monotonic_buffer_resource pool(stack_buf, sizeof stack_buf);
// 单调分配:只分配不回收,析构时整体归还——帧级/请求级内存的利器
{
std::pmr::vector<int> v(&pool); // 分配全部来自 pool
for (int i = 0; i < 100; ++i)
v.push_back(i);
} // 释放是 O(1):pool 整体丢弃 原理:std::pmr::vector<T> 固定使用 std::pmr::polymorphic_allocator,通过虚接口 memory_resource::allocate() 分配,运行期可换内存源而容器类型不变——传统 allocator 换一个就得换容器类型。
scoped_lock
一个 RAII 锁对象管理多个互斥量,内部用 std::lock 思路避免死锁:
void transfer(Account& from, Account& to, long amount) {
std::scoped_lock lk(from.mtx, to.mtx); // 两把锁一次性安全获取
from.withdraw(amount);
to.deposit(amount);
} // 析构按逆序解锁 scoped_lock 还统一了 lock_guard/unique_lock 的常规场景:普通单锁场景 std::scoped_lock lk(m) 与 lock_guard 等价,建议默认写它。
一些小的扩展
std::shared_mutex rw; // 不带定时的读写锁(比 shared_timed_mutex 更轻)
int c = std::clamp(42, 0, 10); // 夹到区间 -> 10
auto g = std::gcd(24, 36); // 最大公约数 -> 12
auto l = std::lcm(4, 6); // 最小公倍数 -> 12
auto nf = std::not_fn(is_odd); // 谓词取反:nf(3) == false
auto n1 = std::size(v); // free 函数版 size/empty/data,泛型代码统一入口
auto& r = std::as_const(obj); // 给对象加 const 引用(不拷贝) 小结
C++17 没有单一「大杀器」,但它改变了日常代码的密度:结构化绑定与 CTAD 削掉大量样板,if constexpr 取代 SFINAE,词汇类型(optional/variant/string_view)让接口意图显式化。主流编译器对 C++17 的支持已经完备,是当下新项目的稳妥基线。
系列其他篇目:现代 C++ 新特性汇总 · C++11 新特性 · C++14 新特性 · C++20 新特性 · C++23 新特性